iT邦幫忙

2024 iThome 鐵人賽

DAY 27
0
Modern Web

與 AI 一起開發 Side Project 吧!系列 第 27

Day27 — 方興未艾 | PDCA 循環,跑到最後吧! (Part2)

  • 分享至 

  • xImage
  •  

前情提要

剩 3 個功能

上回已經做好了「讀取」,這次要來著手進行剩下的三個,有了先前的準備,準備了 AccountingRepository 這個負責與後端溝通的儲存庫,測試也有寫了,想必這次應該能加快速度,一次寫完 3 個功能了吧!

不試試看不知道,雖然沒有像梁靜茹那麼有勇氣,但總要對自己有信心吧! ⛳

下個階段:新增、修改和刪除

Plan 先寫需求

雖然信心是有了,但需求依舊還是三個,一樣是分別進行:

  • 新增:要能夠新增一筆帳目,下次回來使用 App 時還會看到(能夠成功讀取到上次新增的帳目,該紀錄是「持久化」的)。
  • 刪除:同上,可以成功刪除帳目。
  • 修改:一樣,可以成功修改帳目。

首先,記帳 App 要能夠新增,於是寫了如下的使用者故事:

作為一名使用者
我希望可以儲存新增的帳目
以便下次回來回頭查找帳目

一樣請 AI 幫我列成 BDD feature 格式,轉出來的結果為:

Feature: 儲存新增的帳目

  Scenario: 儲存一筆新的帳目
    Given 應用程序已啟動
    When 我新增一筆帳目,金額為 100 元,類別為「飲食」
    Then 我應該看到該帳目已成功儲存
    And 我應該能在我的帳目紀錄中找到這筆帳目

Do(1) BDD → 測試 → 測試待確認

好了,那這次其實跟之前寫過的測試有點像,都是確保「新增帳目」之後,帳目有儲存起來,可以看得到「先前」的帳目紀錄。

於是我打算請 AI 參考舊有的測試,幫我根據此次新的需求,寫個新的測試:

幫我參考 adds new item to history when category is selected and confirms 這個測試,並按照 @create_and_save_accounting.feature 的需求,生成一筆新的測試。

此測試需要測得「新增之後」,再次 render 之後,還可以看得到該帳目。

根據此指令,AI(claude-sonnet)給了我看起來有模有樣的結果,但應該是有點問題 🤔 …

看到的主要問題點有兩個:

  • 模擬保存操作:這個我希望用「local data」來模擬行為,而非只是驗證方法是否被呼叫。 但這個可以先保留,不急著把這項驗證刪除掉。
  • 清除 DOM 並重新渲染應用:AI 給我的實作是 jest.clearAllMocks(),這八成不是這樣寫,但我也不確定寫法,等等實作功能之後再回頭看看。
it('saves a new accounting entry and displays it after re-rendering', async () => {
    let historyList: HistoryItem[] = [];
    const ACCOUNTING_ENTRY: HistoryItem = { id: 1, category: '飲食', amount: 100 };

    // 首次渲染應用
    await whenRender();

    // 新增一筆帳目
    whenInputNumber(ACCOUNTING_ENTRY.amount);
    whenClickOK();
    whenSelectCategory(ACCOUNTING_ENTRY.category);
    whenClickOK();

    // 確認帳目已新增
    thenCategoryShouldHave(ACCOUNTING_ENTRY.category);
    thenAmountShouldBe(`$${ACCOUNTING_ENTRY.amount}`);

    // 模擬保存操作
    const mockSaveEntry = jest.spyOn(AccountingRepositoryImpl.prototype, 'saveEntry').mockImplementation(async () => {
      // 模擬保存操作,這裡可以將新帳目加入到歷史列表中
      historyList.push(ACCOUNTING_ENTRY);
    });

    // 初始化空的歷史列表
    jest.spyOn(AccountingRepositoryImpl.prototype, 'getEntries').mockResolvedValue(historyList);

    await waitFor(() => {
      expect(mockSaveEntry).toHaveBeenCalledWith(expect.objectContaining({
        category: ACCOUNTING_ENTRY.category,
        amount: ACCOUNTING_ENTRY.amount
      }));
    });

    // 清除 DOM 並重新渲染應用
    jest.clearAllMocks();
    jest.spyOn(AccountingRepositoryImpl.prototype, 'getEntries').mockResolvedValue(historyList);

    await whenRender();

    // 確認重新渲染後仍能看到該帳目
    await waitFor(() => {
      thenCategoryShouldHave('飲食');
      thenAmountShouldBe('$100');
    });
  });

Do(2) 測試 → 開發功能

AI 幫我把測試寫好了,而它「幻想」 AccountingRepository 會有個 saveEntry 的方法,負責把新增的帳目給記起來。但剛剛還沒有,所以測試直接報錯:「Property saveEntry does not exist in the provided object」

既然 AI 生成的結果已經幫我預想好 class 的方法了,而我目前也沒什麼好想法,「應觀眾要求」就替 AccountingRepository 加上這個方法吧!

指令很簡單,在 AccountingRepository 這個檔案,開啟 Prompt 視窗,提供以下指令即可。

在 interface 加上 saveEntry 方法,實作亦同

但這樣只是把方法加上去還不夠,測試依舊沒通過,重點的驗測「saveEntry 會被呼叫」這個還沒通過,也就提醒了我根本還沒在 App 中實作儲存的功能。

原本想說要不要在 method 裡面下指令就好,或是用 useEffect 做處理?但不想要顧慮這麼多,就在 App 這個組件直接下指令。

幫我在其中實作 @AccountingRepository.ts 的 saveEntry 功能

AI 幫我直接在 handleSelectCategory 這個「確認」的方法,加上了 saveEntry 的處理。

const handleSelectCategory = async (category: string) => {
    //...
    
    // Save the entry using the repository
    await accountingRepository.saveEntry({ amount: parsedAmount, category, id: Date.now() });
};

這樣一來,新增並儲存的功能已經實作於其中,再跑一下測試。很好,測試通過了!🟢 

Check 重構整理

現在功能完成,測試也通過了,事不宜遲把剛剛有所疑慮的「 清除 DOM 並重新渲染應用」,這部分來問問 AI ,測試一下寫法 🤔

但在嘗試解除疑慮之前,看到一個寫法得先調整,否則此項測試就不可靠了,因為原有的測試可能沒有測到真正該側的邏輯。

要調整的是「準備資料」的呼叫順序,也就是 spyOn 的呼叫之處,要改為放在 whenRender() 之前。原先把 spyOn 放在 render 之後,都已經 render 完了,是要 spy 個毛 😂

接著,來試試看 jest.clearAllMocks() 到底有沒有如註解說明般那樣可以「清空畫面」。於是我先試著把準備資料那行的 jest.spyOn(…) 註解掉,如果「測試通過」,那代表根本沒有把畫面渲染給清除掉(沒把畫面刷新)。

jest.clearAllMocks();

    // jest.spyOn(AccountingRepositoryImpl.prototype, 'getEntries').mockResolvedValue(historyList);

await whenRender();   
 
await waitFor(() => {
  thenCategoryShouldHave('飲食');
  thenAmountShouldBe('$100');
});

果不其然,測試還是通過了,表示畫面根本沒被清空,還是「前一個畫面」的組件。那到底要怎樣做,才會真的「刷新畫面」,確保組件是「全新」的一塊呢?

這部分輸入好多次指令,試了各種下指令的方法,AI 都沒有給出有用的回答。請 AI 幫忙做頗為挫折,無論怎麼給指令,都沒有做到真正刷新畫面。

AI 不是給 jest.clearAllMocks() 就是給 jest.resetModules() 之類的方法,完全都跟「重新刷新畫面」沒關係,這根本是雞同鴨講 XD

最後爬了一下資料,回頭看了 react-testing-library 這個套件的原始碼檔案,看到下方有個 cleanup() 的方法,可以把 mounted 的組件給「解除安裝」。

看來就是它了!於是試試看使用 cleanup() 方法取代原先的 jest.XXX

賓果 🎯! 就是這個方法沒錯! 在沒有準備 AccountingRepositoryImpl 回傳值的前提,測試會出錯。表示 cleanup() 之後再做 whenRender() ,會是個「全新的畫面」。

修改了一番之後,測試總算通過了(功能都沒動),可以預期「新增和讀取」都可以正常運作囉。

最後,測試重構一下,為了往後的測試方便共用,且增加其閱讀性,想要把 historyList 放到整個測試範圍層級,並且我想把 saveEntry 抽成共用的函式,於是請 AI 幫我「代勞」:

幫我重構
saves a new accounting entry and displays it after re-rendering
這個測試的 

let historyList: HistoryItem[] = [];
和
const mockSaveEntry = jest.spyOn(AccountingRepositoryImpl.prototype, 'saveEntry').mockImplementation(async () => {
      historyList.push(ACCOUNTING_ENTRY);
    });

幫我重構為整個測試共用的函式

最後產出的結果還算滿意,將 historyList 和 saveEntry 搬移到 beforeEach 的函式內,並且把相對應的測試做了調整。重構完之後,測試依舊是「好的」,很好!

describe('AccountingApp', () => {
  let historyList: HistoryItem[];
  let mockSaveEntry: jest.SpyInstance;

  beforeEach(() => {
    jest.clearAllMocks();
    historyList = [];
    
    jest.spyOn(AccountingRepositoryImpl.prototype, 'getEntries').mockResolvedValue([]);
    
    mockSaveEntry = jest.spyOn(AccountingRepositoryImpl.prototype, 'saveEntry')
      .mockImplementation(async (entry) => {
        historyList.push({ ...entry, id: historyList.length + 1 });
      });
  });

Action 覆盤

至此為止,PDCA 小循環做開發新功能,大家經過兩次的範例,如果也有自己試著做做看,應該有點感覺了。

雖然還剩下刪除和修改這兩個功能,但今天就到此為止。最後,就用這個範例做結尾。畢竟再繼續介紹下去,各位可以學到知識的邊際效益便不高了。

剩下的兩個功能,大家有興趣可以自己試著做做看呦!

結語

PDCA 循環,莫急莫慌

這次與上次最不一樣的地方,在於「承接上一次」的開發結果,接續著先前的程式碼結果做開發。

你會想說,這兩篇提到的,不就跟更早之前文章提到的開發過程差不多?哪有什麼差別?

是沒有什麼突破性的差別,但我們把「開發時程縮短」,讓單個功能的循環週期,變得清楚可見於單篇文章,而非散落在各篇文章中。

尤其循環的最後(Action),是個覆盤的好機會,放眼過去(此次),展望未來(接下來的開發)。檢視這次的循環產出的程式碼,有沒有可能造成「下一次」開發的困難?

我在做 Read 的時候,接著要做 Create,那 Read 做完的最後,有沒有可以調整,以便之後更方便一點?

此次 Create 開發完成後,再接著做 Delete 或是 Update,是不是有什麼地方可以改得更好,測試有沒有要為「緊接而來」的需求做調整?

這沒有標準答案,也沒有什麼標準 SOP,這仰賴當下開發的需求而定,也取決於舊有程式碼的脈絡而定。

蠻需要靠「想像力」與「經驗」來決定如何調整:想像如果下一個(下個就好,不要想太遠)功能加進來的話,這邊是不是要寫得抽象一點? 有沒有需要提前重構好準備資料?

如果不確定,可以請 AI 幫忙寫寫看,先請 AI 幫忙描繪「想像中程式的樣子」,如果覺得不適合,大不了就砍掉 Code 而已。現在生成程式碼如此「便宜又快速」,無須多慮,不好就砍。

REF


上一篇
Day26 — 方興未艾 | 最後一個功能,PDCA 循環跑起來 (Part1)
下一篇
Day28 — Cursor IDE 使用技巧指南
系列文
與 AI 一起開發 Side Project 吧!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言